switchroom 0.14.57 → 0.14.58

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.
@@ -49463,8 +49463,8 @@ var {
49463
49463
  } = import__.default;
49464
49464
 
49465
49465
  // src/build-info.ts
49466
- var VERSION = "0.14.57";
49467
- var COMMIT_SHA = "ddb0b353";
49466
+ var VERSION = "0.14.58";
49467
+ var COMMIT_SHA = "fc4023ed";
49468
49468
 
49469
49469
  // src/cli/agent.ts
49470
49470
  init_source();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "switchroom",
3
- "version": "0.14.57",
3
+ "version": "0.14.58",
4
4
  "description": "Run Claude Code 24/7 on your Claude Pro/Max subscription over Telegram. Open-source alternative to OpenClaw and NanoClaw — no API keys.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -39372,6 +39372,13 @@ function isFinalAnswerReply(input) {
39372
39372
  return true;
39373
39373
  return false;
39374
39374
  }
39375
+ function isSubstantiveFinalReply(input) {
39376
+ if (input.done === true)
39377
+ return true;
39378
+ if (input.text.length >= FINAL_ANSWER_MIN_CHARS)
39379
+ return true;
39380
+ return false;
39381
+ }
39375
39382
 
39376
39383
  // turn-flush-safety.ts
39377
39384
  var SILENT_MARKERS = new Set(["NO_REPLY", "HEARTBEAT_OK"]);
@@ -47366,6 +47373,28 @@ function shouldArmNoReplyDrain(input) {
47366
47373
  return input.bufferedDepth > 0;
47367
47374
  }
47368
47375
 
47376
+ // gateway/feed-reopen-gate.ts
47377
+ function shouldReopenFeedAfterAck(input) {
47378
+ if (!input.finalAnswerDelivered)
47379
+ return false;
47380
+ if (input.finalAnswerSubstantive)
47381
+ return false;
47382
+ return input.enabled === true;
47383
+ }
47384
+ function decideFeedReopen(input) {
47385
+ if (!shouldReopenFeedAfterAck(input)) {
47386
+ return { dropLabel: true };
47387
+ }
47388
+ return {
47389
+ dropLabel: false,
47390
+ reset: {
47391
+ finalAnswerDelivered: false,
47392
+ activityMessageId: null,
47393
+ activityLastSentRender: null
47394
+ }
47395
+ };
47396
+ }
47397
+
47369
47398
  // gateway/answer-thread-resolve.ts
47370
47399
  function resolveAnswerThreadId(input) {
47371
47400
  if (input.explicitThreadId != null)
@@ -52195,10 +52224,10 @@ function sweepStaleTurnActiveMarker(stateDir, opts) {
52195
52224
  }
52196
52225
 
52197
52226
  // ../src/build-info.ts
52198
- var VERSION = "0.14.57";
52199
- var COMMIT_SHA = "ddb0b353";
52200
- var COMMIT_DATE = "2026-06-03T22:37:37Z";
52201
- var LATEST_PR = 2140;
52227
+ var VERSION = "0.14.58";
52228
+ var COMMIT_SHA = "fc4023ed";
52229
+ var COMMIT_DATE = "2026-06-04T02:50:15Z";
52230
+ var LATEST_PR = 2142;
52202
52231
  var COMMITS_AHEAD_OF_TAG = 0;
52203
52232
 
52204
52233
  // gateway/boot-version.ts
@@ -53403,6 +53432,7 @@ var SERIALIZE_NOREPLY_DRAIN_MS = Number.isFinite(_noReplyDrainParsed) && _noRepl
53403
53432
  var TURN_ORIGIN_ROUTING_ENABLED = process.env.SWITCHROOM_TURN_ORIGIN_ROUTING !== "0";
53404
53433
  var TOPIC_FRAMING_ENABLED = process.env.SWITCHROOM_TOPIC_FRAMING !== "0";
53405
53434
  var QUEUED_STATUS_UX_ENABLED = process.env.SWITCHROOM_QUEUED_STATUS_UX !== "0";
53435
+ var FEED_REOPEN_AFTER_ACK_ENABLED = process.env.SWITCHROOM_FEED_REOPEN_AFTER_ACK !== "0";
53406
53436
  function turnInFlightForGate() {
53407
53437
  return isDeliveryCutoverEnabled() ? isMachineInTurn() : claudeBusyKeys.size > 0;
53408
53438
  }
@@ -55690,6 +55720,10 @@ ${url}`;
55690
55720
  disableNotification
55691
55721
  })) {
55692
55722
  turn2.finalAnswerDelivered = true;
55723
+ turn2.finalAnswerSubstantive = isSubstantiveFinalReply({
55724
+ text: decision.mergedText,
55725
+ disableNotification
55726
+ });
55693
55727
  }
55694
55728
  outboundDedup.record(chat_id, threadId, decision.mergedText, Date.now(), turn2?.registryKey ?? null);
55695
55729
  silentAnchorEditDone = true;
@@ -55889,6 +55923,7 @@ ${url}`;
55889
55923
  noteSignal(statusKey(chat_id, threadId), Date.now());
55890
55924
  if (turn != null && isFinalAnswerReply({ text: rawText, disableNotification })) {
55891
55925
  turn.finalAnswerDelivered = true;
55926
+ turn.finalAnswerSubstantive = isSubstantiveFinalReply({ text: rawText, disableNotification });
55892
55927
  finalizeStatusReaction(chat_id, threadId, "done");
55893
55928
  }
55894
55929
  releaseTurnBufferGate(statusKey(chat_id, threadId), turn ?? undefined);
@@ -56055,6 +56090,11 @@ async function executeStreamReply(args) {
56055
56090
  done: args.done === true
56056
56091
  })) {
56057
56092
  turn.finalAnswerDelivered = true;
56093
+ turn.finalAnswerSubstantive = isSubstantiveFinalReply({
56094
+ text: args.text ?? "",
56095
+ disableNotification: args.disable_notification === true,
56096
+ done: args.done === true
56097
+ });
56058
56098
  const streamThreadIdForClear = args.message_thread_id != null ? Number(args.message_thread_id) : undefined;
56059
56099
  clearSilentEndState(statusKey(streamChatId, streamThreadIdForClear));
56060
56100
  }
@@ -57148,6 +57188,7 @@ function handleSessionEvent(ev) {
57148
57188
  gatewayReceiveAt: startedAt,
57149
57189
  replyCalled: false,
57150
57190
  finalAnswerDelivered: false,
57191
+ finalAnswerSubstantive: false,
57151
57192
  firstPingAt: null,
57152
57193
  silentAnchorMessageId: null,
57153
57194
  silentAnchorText: "",
@@ -57259,8 +57300,18 @@ function handleSessionEvent(ev) {
57259
57300
  return;
57260
57301
  if (isTelegramSurfaceTool(ev.toolName))
57261
57302
  return;
57262
- if (turn.finalAnswerDelivered)
57263
- return;
57303
+ if (turn.finalAnswerDelivered) {
57304
+ const reopen = decideFeedReopen({
57305
+ finalAnswerDelivered: turn.finalAnswerDelivered,
57306
+ finalAnswerSubstantive: turn.finalAnswerSubstantive,
57307
+ enabled: FEED_REOPEN_AFTER_ACK_ENABLED
57308
+ });
57309
+ if (reopen.dropLabel)
57310
+ return;
57311
+ turn.finalAnswerDelivered = reopen.reset.finalAnswerDelivered;
57312
+ turn.activityMessageId = reopen.reset.activityMessageId;
57313
+ turn.activityLastSentRender = reopen.reset.activityLastSentRender;
57314
+ }
57264
57315
  const rendered = appendActivityLabel(turn.mirrorLines, ev.label);
57265
57316
  if (rendered != null) {
57266
57317
  turn.activityPendingRender = composeTurnActivity(turn) ?? rendered;
@@ -57412,6 +57463,7 @@ function handleSessionEvent(ev) {
57412
57463
  turn.answerStream = null;
57413
57464
  streamFinalizedAsAnswer = true;
57414
57465
  turn.finalAnswerDelivered = true;
57466
+ turn.finalAnswerSubstantive = true;
57415
57467
  const oldStreamedMsgId = streamedMsgId;
57416
57468
  (async () => {
57417
57469
  let materializedId;
@@ -57538,6 +57590,7 @@ function handleSessionEvent(ev) {
57538
57590
  }
57539
57591
  }
57540
57592
  turn.finalAnswerDelivered = true;
57593
+ turn.finalAnswerSubstantive = true;
57541
57594
  const cardTakeover = progressDriver?.takeOverCard({
57542
57595
  chatId: backstopChatId,
57543
57596
  threadId: backstopThreadId != null ? String(backstopThreadId) : undefined
@@ -81,3 +81,37 @@ export function isFinalAnswerReply(input: FinalAnswerReplyInput): boolean {
81
81
  if (input.text.length >= FINAL_ANSWER_MIN_CHARS) return true
82
82
  return false
83
83
  }
84
+
85
+ /**
86
+ * Pure predicate: was this reply a *substantive* final answer (as opposed
87
+ * to a reply that is only "final" because it pinged)? `true` if EITHER:
88
+ *
89
+ * - `done === true` — a `stream_reply` terminal call closing the stream.
90
+ * - `text.length >= FINAL_ANSWER_MIN_CHARS` — a substantive-length answer.
91
+ *
92
+ * This is `isFinalAnswerReply` MINUS the notification-only path. The
93
+ * distinction matters for the feed-reopen-after-ack gate
94
+ * (`feed-reopen-gate.ts`): a *short pinging* reply ("on it, checking
95
+ * Brevo…") is classified final by `isFinalAnswerReply` (because it pings)
96
+ * yet is NOT substantive — it is an interim ACK. Only such an ack should
97
+ * cause the live activity feed to re-open when post-ack tool work arrives.
98
+ *
99
+ * A genuine final answer (long, or a stream `done: true`) followed by
100
+ * routine post-answer housekeeping (a memory write / TodoWrite / Bash —
101
+ * none of which are surface tools, so they reach the tool_label handler)
102
+ * must NOT re-open the feed and must NOT reset `finalAnswerDelivered`,
103
+ * otherwise the silent-end re-prompt would spuriously fire and the agent
104
+ * would re-deliver a duplicate / garbled answer.
105
+ *
106
+ * Residual: a reply that is genuinely the final answer yet is BOTH short
107
+ * (<200 chars) AND pinging (e.g. "Done!") is indistinguishable here from
108
+ * an ack, so post-answer housekeeping after it still re-opens the feed.
109
+ * That is much rarer than the housekeeping-after-long-answer case this
110
+ * predicate protects, and is kill-switchable via
111
+ * `SWITCHROOM_FEED_REOPEN_AFTER_ACK=0`.
112
+ */
113
+ export function isSubstantiveFinalReply(input: FinalAnswerReplyInput): boolean {
114
+ if (input.done === true) return true
115
+ if (input.text.length >= FINAL_ANSWER_MIN_CHARS) return true
116
+ return false
117
+ }
@@ -0,0 +1,162 @@
1
+ /**
2
+ * Feed-reopen-after-ack gate (ack-first live-activity visibility).
3
+ *
4
+ * Pure decision: a `tool_label` arrived (the model is calling a tool, i.e.
5
+ * still WORKING) for a turn that has already been classified as having
6
+ * delivered its final answer. Should the gateway *re-open* the live
7
+ * activity feed for the post-ack work?
8
+ *
9
+ * ## The bug this closes
10
+ *
11
+ * In a forum supergroup one agent owns the whole supergroup — a single
12
+ * sequential `claude` CLI with a singleton `currentTurn`. When the model
13
+ * ACKS FIRST ("on it, checking Brevo…") and then does the actual work,
14
+ * that ack reply is classified as the *final answer* by
15
+ * `isFinalAnswerReply` (final-answer-detect.ts) whenever it pings
16
+ * (`!disable_notification`) OR is ≥200 chars — both common for a natural
17
+ * human-feel ack. That sets `turn.finalAnswerDelivered = true`, and the
18
+ * `tool_label` handler's `if (turn.finalAnswerDelivered) return` then
19
+ * drops EVERY subsequent tool label → the live feed goes dark for the
20
+ * real work. The agent looks silent after "On it".
21
+ *
22
+ * ## The decision
23
+ *
24
+ * A new tool label after `finalAnswerDelivered` means the earlier "final"
25
+ * reply MIGHT have been an interim ACK — the turn has NOT delivered its
26
+ * final answer if it is still doing tool work. So reclassify: re-open the
27
+ * feed. The caller then resets `turn.finalAnswerDelivered = false` and
28
+ * `turn.activityMessageId = null` (so a FRESH feed message opens below the
29
+ * ack) and proceeds with the normal append + drain. When the model later
30
+ * sends its REAL final answer, `executeReply` / `stream_reply` re-set
31
+ * `finalAnswerDelivered = true` via `isFinalAnswerReply` and the feed gates
32
+ * off correctly again.
33
+ *
34
+ * ## ACK-ONLY refinement
35
+ *
36
+ * `finalAnswerDelivered` latches true for BOTH a short pinging ack AND a
37
+ * substantive final answer — `isFinalAnswerReply` treats any pinging reply
38
+ * as "final". So reopening unconditionally is HARMFUL after a *genuine*
39
+ * final answer: routine post-answer housekeeping (a memory write /
40
+ * TodoWrite / Bash — none of these are surface tools, so they reach the
41
+ * tool_label handler) fires a tool label → an unconditional reopen would
42
+ * reset `finalAnswerDelivered=false` → the turn-end silent-end re-prompt
43
+ * (`if (turn.finalAnswerDelivered === false)`, NOT gated on zero-outbound)
44
+ * would FIRE → the agent re-delivers a DUPLICATE / garbled answer. Agents
45
+ * routinely write memory after answering, so this would be frequent.
46
+ *
47
+ * The fix: reopen ONLY when the prior reply that set `finalAnswerDelivered`
48
+ * was a SHORT ACK, not a substantive answer. The caller tracks this on the
49
+ * turn as `finalAnswerSubstantive` (set via `isSubstantiveFinalReply` at
50
+ * every site that sets `finalAnswerDelivered = true`). Reopen iff
51
+ * `finalAnswerDelivered && !finalAnswerSubstantive`. When the prior final
52
+ * was substantive, drop the label (legacy gate) — no reopen, no reset — so
53
+ * the silent-end re-prompt and the #2137 drain both see the genuine final
54
+ * correctly.
55
+ *
56
+ * ## Interactions (the reset is correct for all three consumers)
57
+ *
58
+ * 1. #2137 deliver-before-drain gate (`mayDrainBufferedInbound`): reads the
59
+ * ending turn's `finalAnswerDelivered` at turn-end. With the reset, an
60
+ * ack-first turn that is still working keeps it false → the next topic
61
+ * is correctly HELD (no mid-work cross-topic bleed); the bounded
62
+ * no-reply drain timer (~2.5s) still releases the queue if the turn
63
+ * truly ends without a final answer.
64
+ * 2. silent-end re-prompt: a turn that acks, works, then ends with NO real
65
+ * final answer keeps `finalAnswerDelivered=false` → the re-prompt fires
66
+ * (correct — the user got only an ack, no answer).
67
+ * 3. the feed gate itself — this module.
68
+ *
69
+ * ## Kill switch
70
+ *
71
+ * `SWITCHROOM_FEED_REOPEN_AFTER_ACK=0` reverts to the legacy behaviour: a
72
+ * tool label after `finalAnswerDelivered` is dropped (`return`), and the
73
+ * post-ack feed stays dark. The kill switch is read by the CALLER, which
74
+ * passes `enabled` here.
75
+ */
76
+
77
+ export interface FeedReopenInput {
78
+ /** Whether the turn has already been classified as having delivered its
79
+ * final answer (`turn.finalAnswerDelivered`). On an ack-first turn this
80
+ * is set true by the ack reply (it pinged or was ≥200 chars), even
81
+ * though the model is still working. */
82
+ finalAnswerDelivered: boolean
83
+ /** Whether the reply that set `finalAnswerDelivered` was a *substantive*
84
+ * final answer (stream `done`, or ≥200 chars) as opposed to a short
85
+ * pinging interim ACK (`turn.finalAnswerSubstantive`, set via
86
+ * `isSubstantiveFinalReply`). Only a short ACK should re-open the feed:
87
+ * reopening after a genuine final answer + post-answer housekeeping
88
+ * would spuriously trip the silent-end re-prompt → duplicate answer. */
89
+ finalAnswerSubstantive: boolean
90
+ /** Kill-switch state. When false the reopen behaviour is OFF and a tool
91
+ * label after `finalAnswerDelivered` is dropped (legacy). */
92
+ enabled: boolean
93
+ }
94
+
95
+ /**
96
+ * Pure. Given a tool label has just arrived (the model is calling a tool,
97
+ * so it is still working), returns true when the live activity feed should
98
+ * be RE-OPENED for the post-ack work.
99
+ *
100
+ * - !finalAnswerDelivered → false: the feed was never gated off; the normal
101
+ * append/drain path applies (no reopen needed).
102
+ * - finalAnswerDelivered && finalAnswerSubstantive → false: the prior final
103
+ * was a genuine answer (not an ack). Post-answer housekeeping tool work
104
+ * must NOT reopen — keep the legacy gate so the silent-end re-prompt and
105
+ * the #2137 drain see the delivered final correctly.
106
+ * - finalAnswerDelivered && !enabled (kill switch off) → false: legacy
107
+ * behaviour, the label is dropped by the caller.
108
+ * - finalAnswerDelivered && !finalAnswerSubstantive && enabled → true: the
109
+ * "final" reply was a short interim ack; re-open the feed.
110
+ */
111
+ export function shouldReopenFeedAfterAck(input: FeedReopenInput): boolean {
112
+ if (!input.finalAnswerDelivered) return false
113
+ if (input.finalAnswerSubstantive) return false
114
+ return input.enabled === true
115
+ }
116
+
117
+ /** The feed-state fields the caller mutates on reopen. */
118
+ export interface FeedReopenState {
119
+ finalAnswerDelivered: boolean
120
+ activityMessageId: number | null
121
+ activityLastSentRender: string | null
122
+ }
123
+
124
+ /** The branch outcome the tool_label handler takes for a finalAnswer-
125
+ * delivered turn: either drop the label (legacy `return`) or reopen the
126
+ * feed with the given reset state. */
127
+ export interface FeedReopenOutcome {
128
+ /** True → the handler returns early (legacy: label dropped, feed dark). */
129
+ dropLabel: boolean
130
+ /** When dropLabel is false, the new feed-state fields to write on `turn`
131
+ * before the normal append/drain proceeds. */
132
+ reset?: FeedReopenState
133
+ }
134
+
135
+ /**
136
+ * Pure. The complete tool_label decision for a turn already marked
137
+ * finalAnswerDelivered. Mirrors exactly what the gateway handler does:
138
+ * - reopen disabled / substantive final / not applicable → drop the label
139
+ * (legacy `return`); the genuine final answer's gate is preserved.
140
+ * - reopen → reclassify the interim ack: finalAnswerDelivered back to
141
+ * false (the turn has NOT delivered its final answer while still doing
142
+ * tool work), activityMessageId cleared so a FRESH feed message opens
143
+ * below the ack, and activityLastSentRender cleared so the drain loop's
144
+ * `pending !== lastSent` guard never mistakes the fresh render for an
145
+ * already-sent one.
146
+ *
147
+ * Returning the deltas (rather than mutating) keeps the decision unit-
148
+ * testable; the handler applies them to the live `turn` atom.
149
+ */
150
+ export function decideFeedReopen(input: FeedReopenInput): FeedReopenOutcome {
151
+ if (!shouldReopenFeedAfterAck(input)) {
152
+ return { dropLabel: true }
153
+ }
154
+ return {
155
+ dropLabel: false,
156
+ reset: {
157
+ finalAnswerDelivered: false,
158
+ activityMessageId: null,
159
+ activityLastSentRender: null,
160
+ },
161
+ }
162
+ }
@@ -94,7 +94,7 @@ import { classifyInbound } from '../inbound-classifier.js'
94
94
  import * as silencePoke from '../silence-poke.js'
95
95
  import * as pendingProgress from '../pending-work-progress.js'
96
96
  import { writeSilentEndState, clearSilentEndState, recordUndeliveredTurnEnd } from '../silent-end.js'
97
- import { isFinalAnswerReply } from '../final-answer-detect.js'
97
+ import { isFinalAnswerReply, isSubstantiveFinalReply } from '../final-answer-detect.js'
98
98
  import { createAnswerStream, type AnswerStreamHandle } from '../answer-stream.js'
99
99
  import { parseVisibleAnswerStreamEnabled } from '../answer-stream-flag.js'
100
100
  import { type SessionEvent } from '../session-tail.js'
@@ -282,6 +282,7 @@ import { createInboundSpool } from './inbound-spool.js'
282
282
  import { purgeStaleTurnsForChat } from './turn-state-purge.js'
283
283
  import { decideInboundDelivery } from './inbound-delivery-gate.js'
284
284
  import { mayDrainBufferedInbound, shouldArmNoReplyDrain } from './serialize-drain-gate.js'
285
+ import { decideFeedReopen } from './feed-reopen-gate.js'
285
286
  import { resolveAnswerThreadId } from './answer-thread-resolve.js'
286
287
  import {
287
288
  createDeliveryQueue,
@@ -1419,6 +1420,16 @@ const TOPIC_FRAMING_ENABLED =
1419
1420
  // → no placeholder (the 👀 ack reaction still fires). Delete-on-answer.
1420
1421
  const QUEUED_STATUS_UX_ENABLED =
1421
1422
  process.env.SWITCHROOM_QUEUED_STATUS_UX !== '0'
1423
+ // Feed-reopen-after-ack. When a tool label arrives for a turn already
1424
+ // marked finalAnswerDelivered, the model is still WORKING — so the earlier
1425
+ // "final" reply was an interim ACK (an ack-first reply pings or runs ≥200
1426
+ // chars, both of which isFinalAnswerReply classifies as final). Re-open the
1427
+ // live activity feed for the post-ack work instead of dropping the label.
1428
+ // Kill switch off (=0) → legacy behaviour: the label is dropped and the
1429
+ // post-ack feed stays dark. See `feed-reopen-gate.ts` for the rationale and
1430
+ // the finalAnswerDelivered-consumer interactions.
1431
+ const FEED_REOPEN_AFTER_ACK_ENABLED =
1432
+ process.env.SWITCHROOM_FEED_REOPEN_AFTER_ACK !== '0'
1422
1433
 
1423
1434
  /**
1424
1435
  * Authoritative "is a turn in flight?" for every gate that previously
@@ -1552,6 +1563,20 @@ type CurrentTurn = {
1552
1563
  // even though `replyCalled` is true — the #1664 case where the real answer
1553
1564
  // ended up as plain transcript text rendered into an ephemeral draft.
1554
1565
  finalAnswerDelivered: boolean
1566
+ // Feed-reopen-after-ack refinement — whether the reply that set
1567
+ // `finalAnswerDelivered` was a *substantive* final answer (stream
1568
+ // `done`, or ≥200 chars) as opposed to a short pinging interim ACK.
1569
+ // Set via `isSubstantiveFinalReply` at every site that sets
1570
+ // `finalAnswerDelivered = true`. The tool_label handler re-opens the
1571
+ // live activity feed ONLY when `finalAnswerDelivered && !finalAnswer-
1572
+ // Substantive` (the prior "final" was an ack). After a genuine final
1573
+ // answer this stays true, so routine post-answer housekeeping (memory
1574
+ // write / TodoWrite / Bash — non-surface tools that reach the handler)
1575
+ // does NOT reopen and does NOT reset `finalAnswerDelivered`, which would
1576
+ // otherwise spuriously trip the silent-end re-prompt → duplicate answer.
1577
+ // Reset to false on every fresh-turn enqueue alongside
1578
+ // `finalAnswerDelivered`.
1579
+ finalAnswerSubstantive: boolean
1555
1580
  // #1675 (over-ping safety net): wall-clock ms of the first reply
1556
1581
  // this turn that landed with `disable_notification: false` (a real
1557
1582
  // device ping). The conversational-pacing contract
@@ -6305,6 +6330,12 @@ async function executeReply(args: Record<string, unknown>): Promise<{ content: A
6305
6330
  })
6306
6331
  ) {
6307
6332
  turn.finalAnswerDelivered = true
6333
+ // Feed-reopen refinement: a substantive merged silent-anchor
6334
+ // answer must NOT re-open the feed on post-answer housekeeping.
6335
+ turn.finalAnswerSubstantive = isSubstantiveFinalReply({
6336
+ text: decision.mergedText,
6337
+ disableNotification,
6338
+ })
6308
6339
  }
6309
6340
  outboundDedup.record(
6310
6341
  chat_id,
@@ -6644,6 +6675,10 @@ async function executeReply(args: Record<string, unknown>): Promise<{ content: A
6644
6675
  // end re-prompt from spuriously firing on a delivered final.
6645
6676
  if (turn != null && isFinalAnswerReply({ text: rawText, disableNotification })) {
6646
6677
  turn.finalAnswerDelivered = true
6678
+ // Feed-reopen refinement: track whether this final was substantive
6679
+ // (≥200 chars or stream-done — not a short pinging ack) so post-answer
6680
+ // housekeeping tool work does NOT re-open the feed / trip silent-end.
6681
+ turn.finalAnswerSubstantive = isSubstantiveFinalReply({ text: rawText, disableNotification })
6647
6682
  // #1728: release the buffer gate + emit terminal 👍. Mid-turn
6648
6683
  // acks bypass this branch and remain non-events for the
6649
6684
  // reaction (preserves #1713). The full turn-state teardown
@@ -6987,6 +7022,14 @@ async function executeStreamReply(args: Record<string, unknown>): Promise<unknow
6987
7022
  })
6988
7023
  ) {
6989
7024
  turn.finalAnswerDelivered = true
7025
+ // Feed-reopen refinement: a stream_reply done=true (or a ≥200-char
7026
+ // chunk) is substantive; a short pinging non-done chunk is an ack. Only
7027
+ // the latter should re-open the feed on subsequent post-answer work.
7028
+ turn.finalAnswerSubstantive = isSubstantiveFinalReply({
7029
+ text: (args.text as string | undefined) ?? '',
7030
+ disableNotification: args.disable_notification === true,
7031
+ done: args.done === true,
7032
+ })
6990
7033
  // #1744 follow-up — stream_reply edge case. The first-emit gate at
6991
7034
  // L5178 only clears silent-end state on the FIRST emit of a stream.
6992
7035
  // If a stream's first emit was ack-shaped (disable_notification:true,
@@ -8577,6 +8620,7 @@ function handleSessionEvent(ev: SessionEvent): void {
8577
8620
  gatewayReceiveAt: startedAt,
8578
8621
  replyCalled: false,
8579
8622
  finalAnswerDelivered: false,
8623
+ finalAnswerSubstantive: false,
8580
8624
  firstPingAt: null,
8581
8625
  silentAnchorMessageId: null,
8582
8626
  silentAnchorText: '',
@@ -8794,7 +8838,53 @@ function handleSessionEvent(ev: SessionEvent): void {
8794
8838
  // the FINAL answer would re-`sendMessage` a fresh feed below it (flicker).
8795
8839
  // Safe ordering: `tool_label` is real-time (PreToolUse, ~250ms) while
8796
8840
  // `finalAnswerDelivered` is set from executeReply on the final answer.
8797
- if (turn.finalAnswerDelivered) return
8841
+ //
8842
+ // Feed-reopen-after-ack: a tool label here means the model is STILL
8843
+ // working. If the turn was already marked finalAnswerDelivered, the
8844
+ // "final" reply MIGHT have been an interim ACK ("on it, checking
8845
+ // Brevo…" pings, classified final by isFinalAnswerReply), so the
8846
+ // post-ack work had no live feed — the gate above dropped every label.
8847
+ //
8848
+ // ACK-ONLY refinement: finalAnswerDelivered latches true for BOTH a
8849
+ // short pinging ack AND a substantive answer. Reopening unconditionally
8850
+ // is harmful after a GENUINE final answer — routine post-answer
8851
+ // housekeeping (memory write / TodoWrite / Bash; non-surface tools that
8852
+ // reach here) would reset finalAnswerDelivered=false and trip the
8853
+ // silent-end re-prompt (NOT zero-outbound gated) → duplicate answer. So
8854
+ // reopen ONLY when the prior final was a short ack
8855
+ // (finalAnswerSubstantive=false). When it was substantive, drop the
8856
+ // label (legacy gate) so the genuine final stays delivered.
8857
+ //
8858
+ // On reopen: reclassify the interim ack — the turn has NOT delivered its
8859
+ // final answer while still doing tool work. Reset the flag and clear
8860
+ // activityMessageId so a FRESH feed message opens below the ack, then
8861
+ // proceed normally. When the model's REAL final answer lands,
8862
+ // executeReply / stream_reply re-set finalAnswerDelivered=true (and
8863
+ // finalAnswerSubstantive) and the feed gates off again. The reset keeps
8864
+ // the #2137 serialize gate HOLDING the next topic mid-work (next-topic
8865
+ // liveness is the bounded no-reply timer's job) and lets the silent-end
8866
+ // re-prompt fire if the turn ends on only an ack.
8867
+ // Kill switch SWITCHROOM_FEED_REOPEN_AFTER_ACK=0 → legacy `return`.
8868
+ if (turn.finalAnswerDelivered) {
8869
+ // decideFeedReopen returns dropLabel (legacy return) or the reset
8870
+ // deltas: finalAnswerDelivered→false (the turn has NOT delivered its
8871
+ // final answer while still doing tool work), activityMessageId→null
8872
+ // (a FRESH feed message opens below the ack), activityLastSentRender
8873
+ // →null (so the drain loop's `pending !== lastSent` guard never
8874
+ // mistakes the fresh render for the ack's finalized one and skips it).
8875
+ const reopen = decideFeedReopen({
8876
+ finalAnswerDelivered: turn.finalAnswerDelivered,
8877
+ // ACK-ONLY: reopen only when the prior final was a short ack, not a
8878
+ // substantive answer — otherwise post-answer housekeeping would
8879
+ // reset finalAnswerDelivered and trip the silent-end re-prompt.
8880
+ finalAnswerSubstantive: turn.finalAnswerSubstantive,
8881
+ enabled: FEED_REOPEN_AFTER_ACK_ENABLED,
8882
+ })
8883
+ if (reopen.dropLabel) return
8884
+ turn.finalAnswerDelivered = reopen.reset!.finalAnswerDelivered
8885
+ turn.activityMessageId = reopen.reset!.activityMessageId
8886
+ turn.activityLastSentRender = reopen.reset!.activityLastSentRender
8887
+ }
8798
8888
  const rendered = appendActivityLabel(turn.mirrorLines, ev.label)
8799
8889
  if (rendered != null) {
8800
8890
  // Recompose so any active foreground sub-agent's nested block (Model A)
@@ -9148,6 +9238,11 @@ function handleSessionEvent(ev: SessionEvent): void {
9148
9238
  turn.answerStream = null
9149
9239
  streamFinalizedAsAnswer = true
9150
9240
  turn.finalAnswerDelivered = true
9241
+ // Feed-reopen refinement: the stream is being finalized as the
9242
+ // turn's answer (the model's terminal text), i.e. done=true by
9243
+ // construction → substantive. Post-answer housekeeping must NOT
9244
+ // re-open the feed.
9245
+ turn.finalAnswerSubstantive = true
9151
9246
  // Capture the old streamed message_id BEFORE materialize so
9152
9247
  // we can delete it after the fresh ping send. materialize()
9153
9248
  // overwrites `streamMsgId` internally with the new send's id;
@@ -9424,6 +9519,12 @@ function handleSessionEvent(ev: SessionEvent): void {
9424
9519
  // it keeps the captured `turn` atom internally consistent for any
9425
9520
  // future reader.)
9426
9521
  turn.finalAnswerDelivered = true
9522
+ // Feed-reopen refinement: turn-flush delivers the model's terminal
9523
+ // transcript text as the genuine answer (not an ack). Default to
9524
+ // substantive so a late tool label does NOT re-open the feed / trip
9525
+ // the silent-end re-prompt. (Belt-and-braces, like the set above —
9526
+ // this branch returns before any further tool_label can arrive.)
9527
+ turn.finalAnswerSubstantive = true
9427
9528
 
9428
9529
  // #654 deterministic double-message fix. Hand off the pinned
9429
9530
  // progress card BEFORE state reset so the driver doesn't keep
@@ -0,0 +1,133 @@
1
+ import { describe, expect, it } from 'vitest'
2
+
3
+ import {
4
+ decideFeedReopen,
5
+ shouldReopenFeedAfterAck,
6
+ } from '../gateway/feed-reopen-gate.js'
7
+
8
+ /**
9
+ * Feed-reopen-after-ack — pure decision gate.
10
+ *
11
+ * A supergroup agent that ACKS FIRST ("on it, checking Brevo…") then works
12
+ * had its live activity feed go dark for the real work: the ack reply is
13
+ * classified as the final answer by isFinalAnswerReply (it pings or is ≥200
14
+ * chars), setting turn.finalAnswerDelivered=true, and the tool_label handler
15
+ * then dropped every subsequent label. This predicate decides whether a tool
16
+ * label arriving after finalAnswerDelivered (the model is still working)
17
+ * should RE-OPEN the feed.
18
+ *
19
+ * ACK-ONLY refinement: finalAnswerDelivered latches true for BOTH a short
20
+ * pinging ack AND a substantive answer. Reopening after a GENUINE final
21
+ * answer is harmful — post-answer housekeeping (memory write / TodoWrite /
22
+ * Bash) would reset finalAnswerDelivered=false and trip the silent-end
23
+ * re-prompt → duplicate answer. So the gate reopens ONLY when the prior
24
+ * final was a short ack (finalAnswerSubstantive=false).
25
+ */
26
+ describe('shouldReopenFeedAfterAck', () => {
27
+ it('reopens when delivered AND NOT substantive AND enabled (the ack-first fix)', () => {
28
+ expect(
29
+ shouldReopenFeedAfterAck({
30
+ finalAnswerDelivered: true,
31
+ finalAnswerSubstantive: false,
32
+ enabled: true,
33
+ }),
34
+ ).toBe(true)
35
+ })
36
+
37
+ it('does NOT reopen when the prior final was SUBSTANTIVE (the new guard)', () => {
38
+ // A real final answer followed by post-answer housekeeping tool work:
39
+ // keep the legacy gate (no reopen) so the silent-end re-prompt and the
40
+ // #2137 drain see the delivered final correctly. This is the harmful
41
+ // case the refinement closes.
42
+ expect(
43
+ shouldReopenFeedAfterAck({
44
+ finalAnswerDelivered: true,
45
+ finalAnswerSubstantive: true,
46
+ enabled: true,
47
+ }),
48
+ ).toBe(false)
49
+ })
50
+
51
+ it('does NOT reopen when the kill switch is off (legacy: drop the label)', () => {
52
+ expect(
53
+ shouldReopenFeedAfterAck({
54
+ finalAnswerDelivered: true,
55
+ finalAnswerSubstantive: false,
56
+ enabled: false,
57
+ }),
58
+ ).toBe(false)
59
+ })
60
+
61
+ it('does NOT reopen when the final answer was never delivered (no reopen needed)', () => {
62
+ // The feed was never gated off — the normal append/drain path applies.
63
+ expect(
64
+ shouldReopenFeedAfterAck({
65
+ finalAnswerDelivered: false,
66
+ finalAnswerSubstantive: false,
67
+ enabled: true,
68
+ }),
69
+ ).toBe(false)
70
+ expect(
71
+ shouldReopenFeedAfterAck({
72
+ finalAnswerDelivered: false,
73
+ finalAnswerSubstantive: false,
74
+ enabled: false,
75
+ }),
76
+ ).toBe(false)
77
+ })
78
+ })
79
+
80
+ describe('decideFeedReopen — tool_label branch outcome for a delivered turn', () => {
81
+ it('tool_label after a SHORT ACK (not substantive, kill switch ON) → reset + render proceeds', () => {
82
+ // The exact contract the gateway tool_label handler applies: the interim
83
+ // ack is reclassified — finalAnswerDelivered back to false, a FRESH feed
84
+ // message (activityMessageId null), last-sent render cleared so the drain
85
+ // re-sends. dropLabel false → the handler proceeds to append + drain.
86
+ const outcome = decideFeedReopen({
87
+ finalAnswerDelivered: true,
88
+ finalAnswerSubstantive: false,
89
+ enabled: true,
90
+ })
91
+ expect(outcome.dropLabel).toBe(false)
92
+ expect(outcome.reset).toEqual({
93
+ finalAnswerDelivered: false,
94
+ activityMessageId: null,
95
+ activityLastSentRender: null,
96
+ })
97
+ })
98
+
99
+ it('tool_label after a SUBSTANTIVE final → drops the label (the new guard, feed stays gated)', () => {
100
+ // Genuine final answer + post-answer housekeeping: NO reopen, NO reset.
101
+ // finalAnswerDelivered stays true so the silent-end re-prompt does not
102
+ // fire and the #2137 drain proceeds correctly.
103
+ const outcome = decideFeedReopen({
104
+ finalAnswerDelivered: true,
105
+ finalAnswerSubstantive: true,
106
+ enabled: true,
107
+ })
108
+ expect(outcome.dropLabel).toBe(true)
109
+ expect(outcome.reset).toBeUndefined()
110
+ })
111
+
112
+ it('kill switch OFF → drops the label (legacy early return, feed stays dark)', () => {
113
+ const outcome = decideFeedReopen({
114
+ finalAnswerDelivered: true,
115
+ finalAnswerSubstantive: false,
116
+ enabled: false,
117
+ })
118
+ expect(outcome.dropLabel).toBe(true)
119
+ expect(outcome.reset).toBeUndefined()
120
+ })
121
+
122
+ it('finalAnswerDelivered false → no reopen branch (handler never reaches it)', () => {
123
+ // The handler only calls decideFeedReopen inside `if (finalAnswerDelivered)`,
124
+ // but the predicate is total: a false flag yields dropLabel (no reset).
125
+ const outcome = decideFeedReopen({
126
+ finalAnswerDelivered: false,
127
+ finalAnswerSubstantive: false,
128
+ enabled: true,
129
+ })
130
+ expect(outcome.dropLabel).toBe(true)
131
+ expect(outcome.reset).toBeUndefined()
132
+ })
133
+ })
@@ -16,7 +16,11 @@
16
16
  */
17
17
 
18
18
  import { describe, it, expect } from 'vitest'
19
- import { isFinalAnswerReply, FINAL_ANSWER_MIN_CHARS } from '../final-answer-detect.js'
19
+ import {
20
+ isFinalAnswerReply,
21
+ isSubstantiveFinalReply,
22
+ FINAL_ANSWER_MIN_CHARS,
23
+ } from '../final-answer-detect.js'
20
24
 
21
25
  describe('isFinalAnswerReply — #1664 final-answer classification', () => {
22
26
  it('classifies a notification-bearing reply as the final answer', () => {
@@ -87,3 +91,65 @@ describe('isFinalAnswerReply — #1664 final-answer classification', () => {
87
91
  expect(FINAL_ANSWER_MIN_CHARS).toBe(200)
88
92
  })
89
93
  })
94
+
95
+ describe('isSubstantiveFinalReply — feed-reopen ACK-ONLY distinction', () => {
96
+ // isSubstantiveFinalReply is isFinalAnswerReply MINUS the ping-only path.
97
+ // It tells "genuine final answer" (stream-done or ≥200 chars) apart from
98
+ // "final only because it pinged" (a short interim ack). The feed-reopen
99
+ // gate reopens only when finalAnswerDelivered && !substantive, so a real
100
+ // answer + post-answer housekeeping does NOT spuriously reopen / trip the
101
+ // silent-end re-prompt.
102
+
103
+ it('stream_reply done=true → substantive (closes the stream = the answer)', () => {
104
+ expect(
105
+ isSubstantiveFinalReply({ text: 'ok', disableNotification: true, done: true }),
106
+ ).toBe(true)
107
+ })
108
+
109
+ it('a reply at/over the length backstop → substantive', () => {
110
+ expect(
111
+ isSubstantiveFinalReply({
112
+ text: 'x'.repeat(FINAL_ANSWER_MIN_CHARS),
113
+ disableNotification: true,
114
+ }),
115
+ ).toBe(true)
116
+ // One under the threshold, silent → not substantive.
117
+ expect(
118
+ isSubstantiveFinalReply({
119
+ text: 'x'.repeat(FINAL_ANSWER_MIN_CHARS - 1),
120
+ disableNotification: true,
121
+ }),
122
+ ).toBe(false)
123
+ })
124
+
125
+ it('a short PINGING reply is final but NOT substantive (the ack case)', () => {
126
+ // The crux: isFinalAnswerReply says true (it pings), but this is the
127
+ // ack the feed-reopen gate must treat as reopen-eligible — NOT a real
128
+ // answer. So isSubstantiveFinalReply must say false.
129
+ expect(
130
+ isFinalAnswerReply({ text: 'on it, checking Brevo…', disableNotification: false }),
131
+ ).toBe(true)
132
+ expect(
133
+ isSubstantiveFinalReply({ text: 'on it, checking Brevo…', disableNotification: false }),
134
+ ).toBe(false)
135
+ })
136
+
137
+ it('a short SILENT interim reply is neither final nor substantive', () => {
138
+ expect(
139
+ isFinalAnswerReply({ text: 'thinking…', disableNotification: true }),
140
+ ).toBe(false)
141
+ expect(
142
+ isSubstantiveFinalReply({ text: 'thinking…', disableNotification: true }),
143
+ ).toBe(false)
144
+ })
145
+
146
+ it('a long reply is substantive regardless of the ping flag', () => {
147
+ const longText = 'x'.repeat(FINAL_ANSWER_MIN_CHARS)
148
+ expect(
149
+ isSubstantiveFinalReply({ text: longText, disableNotification: false }),
150
+ ).toBe(true)
151
+ expect(
152
+ isSubstantiveFinalReply({ text: longText, disableNotification: true }),
153
+ ).toBe(true)
154
+ })
155
+ })