switchroom 0.13.12 → 0.13.14

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.
@@ -0,0 +1,83 @@
1
+ /**
2
+ * final-answer-detect.ts — #1664 "did this reply deliver the final answer?"
3
+ *
4
+ * Background. An agent often ends a turn with its real answer as plain
5
+ * assistant transcript text instead of a `reply` / `stream_reply` tool
6
+ * call. The gateway renders that transcript as a live Telegram draft
7
+ * (`sendMessageDraft`) and, at turn_end, retracts the draft — so the
8
+ * answer is never finalized and the user watches it vanish (#1664).
9
+ *
10
+ * The gateway's `replyCalled` flag flips on the FIRST reply / stream_reply
11
+ * tool use and stays true for the rest of the turn. It cannot distinguish
12
+ * "the model sent an interim ack" from "the model sent its real answer" —
13
+ * both set `replyCalled`. The silent-end re-prompt safety net needs a
14
+ * finer signal: it must engage when a turn ended with only an interim
15
+ * ack and the real answer left as transcript text.
16
+ *
17
+ * This module is that finer signal — a pure predicate the gateway calls
18
+ * for each reply that lands. A turn whose every reply was classified
19
+ * "interim" ends with `CurrentTurn.finalAnswerDelivered === false`, which
20
+ * triggers the re-prompt; a turn with at least one "final" reply does not.
21
+ *
22
+ * Keeping the policy in one unit-testable function is the point — the
23
+ * gateway is a multi-thousand-line module that's expensive to import in a
24
+ * test. See `telegram-plugin/tests/final-answer-detect.test.ts`.
25
+ *
26
+ * The fix re-prompts the model; it never materializes the draft into a
27
+ * message (`reference/principles.md`: the model communicates, the
28
+ * framework is the safety net). So a false "interim" classification is
29
+ * cheap (one extra re-prompt) and a false "final" classification is the
30
+ * dangerous one (a real answer left undelivered) — the length backstop
31
+ * exists to make the dangerous miss rare.
32
+ */
33
+
34
+ /**
35
+ * Length backstop for the final-answer classification. The pacing
36
+ * contract (`docs/telegram-style.md`) says interim updates pass
37
+ * `disable_notification: true` and the final answer omits it — so a
38
+ * notification-bearing reply is the primary "final answer" signal. But a
39
+ * model that mis-marks a genuinely substantive reply as interim
40
+ * (`disable_notification: true` on what is really the answer) would
41
+ * otherwise leave the turn looking undelivered. Any reply at or above
42
+ * this many characters therefore ALSO counts as the final answer,
43
+ * regardless of the notification flag. 200 chars is comfortably longer
44
+ * than a typical interim ack ("on it", "looking into that…", "give me a
45
+ * sec") and short enough that a real answer almost always clears it.
46
+ */
47
+ export const FINAL_ANSWER_MIN_CHARS = 200
48
+
49
+ export interface FinalAnswerReplyInput {
50
+ /** The reply text the model sent (the model's own answer text, before
51
+ * any HTML conversion or Telegraph-link substitution). */
52
+ text: string
53
+ /** The `disable_notification` argument the reply tool was called with.
54
+ * `true` is the pacing contract's "interim update" marker; the final
55
+ * answer omits it (effectively `false`). */
56
+ disableNotification: boolean
57
+ /** For `stream_reply` only: whether this call carried `done: true`. A
58
+ * `done: true` call explicitly closes the stream and IS the final
59
+ * answer by definition. Pass `false` for the plain `reply` tool. */
60
+ done?: boolean
61
+ }
62
+
63
+ /**
64
+ * Pure predicate: did this reply deliver the turn's final answer (as
65
+ * opposed to an interim ack)? `true` if ANY of:
66
+ *
67
+ * - `done === true` — a `stream_reply` terminal call; the model
68
+ * explicitly closed the stream, so this is the final answer.
69
+ * - `disableNotification === false` — the pacing contract's explicit
70
+ * "final answer" signal (interim updates set it `true`).
71
+ * - `text.length >= FINAL_ANSWER_MIN_CHARS` — the length backstop for
72
+ * a substantive answer mis-marked as interim.
73
+ *
74
+ * The gateway ORs this across every reply in a turn; once one reply
75
+ * qualifies, `CurrentTurn.finalAnswerDelivered` latches true and the
76
+ * silent-end re-prompt will not engage for that turn.
77
+ */
78
+ export function isFinalAnswerReply(input: FinalAnswerReplyInput): boolean {
79
+ if (input.done === true) return true
80
+ if (!input.disableNotification) return true
81
+ if (input.text.length >= FINAL_ANSWER_MIN_CHARS) return true
82
+ return false
83
+ }
@@ -76,7 +76,9 @@ import {
76
76
  import { emitRuntimeMetric } from '../runtime-metrics.js'
77
77
  import { classifyInbound } from '../inbound-classifier.js'
78
78
  import * as silencePoke from '../silence-poke.js'
79
- import { writeSilentEndState, clearSilentEndState, recordSilentTurnEnd } from '../silent-end.js'
79
+ import * as pendingProgress from '../pending-work-progress.js'
80
+ import { writeSilentEndState, clearSilentEndState, recordUndeliveredTurnEnd } from '../silent-end.js'
81
+ import { isFinalAnswerReply } from '../final-answer-detect.js'
80
82
  import { createAnswerStream, type AnswerStreamHandle } from '../answer-stream.js'
81
83
  import { type SessionEvent } from '../session-tail.js'
82
84
  import {
@@ -1191,6 +1193,19 @@ type CurrentTurn = {
1191
1193
  startedAt: number
1192
1194
  gatewayReceiveAt: number
1193
1195
  replyCalled: boolean
1196
+ // #1664 — whether the model has delivered its *final answer* this turn
1197
+ // (as opposed to only an interim ack). `replyCalled` flips on the first
1198
+ // reply / stream_reply tool_use and stays true for the rest of the turn,
1199
+ // so it cannot tell "ack only" from "ack + real answer". This flag is the
1200
+ // finer signal the silent-end re-prompt needs: it is set only when a reply
1201
+ // actually lands AND `isFinalAnswerReply` (final-answer-detect.ts)
1202
+ // classifies it as the final answer — notification-bearing, or long
1203
+ // enough to be substantive, or a stream_reply done=true — OR when the
1204
+ // turn-flush safety net legitimately emits the model's terminal text. A
1205
+ // turn that ends with this still `false` triggers the silent-end re-prompt
1206
+ // even though `replyCalled` is true — the #1664 case where the real answer
1207
+ // ended up as plain transcript text rendered into an ephemeral draft.
1208
+ finalAnswerDelivered: boolean
1194
1209
  capturedText: string[]
1195
1210
  orphanedReplyTimeoutId: ReturnType<typeof setTimeout> | null
1196
1211
  registryKey: string | null
@@ -3135,6 +3150,7 @@ silencePoke.startTimer({
3135
3150
  // Drop silence-poke state and clear turn-active so the next inbound
3136
3151
  // for this chat starts a fresh turn instead of queueing forever.
3137
3152
  silencePoke.endTurn(fbKey)
3153
+ pendingProgress.noteTurnEnd(fbKey)
3138
3154
  purgeReactionTracking(fbKey)
3139
3155
  // Defense-in-depth: the fallback's purgeReactionTracking above
3140
3156
  // clears the canonical statusKey(chatId, threadId) for fbKey
@@ -3192,6 +3208,34 @@ silencePoke.startTimer({
3192
3208
  },
3193
3209
  })
3194
3210
 
3211
+ // #1445 cross-turn pending-async ambient. When a turn ends after the
3212
+ // model dispatched background async work (Agent / Task / Bash run-in-
3213
+ // background) and the model has stopped speaking, keep editing the
3214
+ // model's last reply in place at 60s intervals so the user sees
3215
+ // ambient liveness during the wait. Edits are silent, never spawn a
3216
+ // new pinged message, and stop the moment the user re-engages or the
3217
+ // model synthesises a handback. The full design rationale lives in
3218
+ // `pending-work-progress.ts`'s header docblock. Kill switch:
3219
+ // `SWITCHROOM_DISABLE_PENDING_PROGRESS=1`.
3220
+ pendingProgress.startTimer({
3221
+ editMessage: async (ctx) => {
3222
+ await swallowingApiCall(
3223
+ () =>
3224
+ lockedBot.api.editMessageText(
3225
+ ctx.chatId,
3226
+ ctx.messageId,
3227
+ ctx.newText,
3228
+ ),
3229
+ {
3230
+ chat_id: ctx.chatId,
3231
+ verb: 'pending-progress-edit',
3232
+ ...(ctx.threadId != null ? { threadId: ctx.threadId } : {}),
3233
+ },
3234
+ )
3235
+ },
3236
+ emitMetric: (event) => emitRuntimeMetric(event),
3237
+ })
3238
+
3195
3239
  // Per-agent buffer for synthetic inbounds the gateway couldn't deliver
3196
3240
  // because the bridge wasn't connected at send-time. Drained on
3197
3241
  // bridge-register so a fresh client picks up missed wake-ups before
@@ -3564,6 +3608,22 @@ const ipcServer: IpcServer = createIpcServer({
3564
3608
  label.length > 0 ? label : null,
3565
3609
  Date.now(),
3566
3610
  )
3611
+ // #1445 cross-turn pending-async ambient. Mark the chat as
3612
+ // having dispatched background work this turn so a turn_end
3613
+ // that follows activates the edit-in-place ambient line.
3614
+ // Covers `Agent` / `Task` (the harness-managed async path
3615
+ // — handback channel turn clears it) and `Bash` with
3616
+ // run_in_background:true (model is expected to poll
3617
+ // BashOutput; the ambient ticks until next inbound or the
3618
+ // 30-min budget cap).
3619
+ const evInput = ev.input as { run_in_background?: boolean } | undefined
3620
+ if (
3621
+ ev.toolName === 'Agent'
3622
+ || ev.toolName === 'Task'
3623
+ || (ev.toolName === 'Bash' && evInput?.run_in_background === true)
3624
+ ) {
3625
+ pendingProgress.noteAsyncDispatch(key)
3626
+ }
3567
3627
  }
3568
3628
  } else if (ev.kind === 'tool_result') {
3569
3629
  // #1292: drain the in-flight entry. Idempotent on unknown ids
@@ -4066,6 +4126,13 @@ async function executeUpdateChecklist(args: Record<string, unknown>): Promise<{
4066
4126
  }
4067
4127
 
4068
4128
  async function executeReply(args: Record<string, unknown>): Promise<{ content: Array<{ type: string; text: string }> }> {
4129
+ // #1664 — pin the turn this reply belongs to at entry. The
4130
+ // finalAnswerDelivered write near the end of this function runs after
4131
+ // several awaits; turn-pinning (the #1067 pattern used across the
4132
+ // gateway) keeps the write attributed to THIS turn rather than reading
4133
+ // module-scope currentTurn, which a future refactor could let roll over
4134
+ // mid-call.
4135
+ const turn = currentTurn
4069
4136
  const chat_id = args.chat_id as string
4070
4137
  if (!chat_id) throw new Error('reply: chat_id is required')
4071
4138
  const rawText = args.text as string | undefined
@@ -4370,6 +4437,22 @@ async function executeReply(args: Record<string, unknown>): Promise<{ content: A
4370
4437
  }
4371
4438
  }
4372
4439
 
4440
+ // #1445 cross-turn pending-async ambient. Capture the last text
4441
+ // chunk as the anchor — if this turn ends with a pending async
4442
+ // dispatch, the framework edits THIS message in place every 60s
4443
+ // with a `— still working (Nm)` suffix until the user re-engages.
4444
+ // Multi-chunk replies: anchor is the LAST chunk (edits append to
4445
+ // the visually-trailing message; earlier chunks are left intact).
4446
+ if (sentIds.length === chunks.length && chunks.length > 0) {
4447
+ const anchorMsgId = sentIds[chunks.length - 1]
4448
+ if (typeof anchorMsgId === 'number') {
4449
+ pendingProgress.noteOutbound(statusKey(chat_id, threadId), {
4450
+ messageId: anchorMsgId,
4451
+ text: chunks[chunks.length - 1],
4452
+ })
4453
+ }
4454
+ }
4455
+
4373
4456
  // #273: when files is 2-10 photos, batch them into a single
4374
4457
  // sendMediaGroup album rather than N separate sendPhoto calls. The
4375
4458
  // user's device fires one notification for the album instead of N
@@ -4488,6 +4571,19 @@ async function executeReply(args: Record<string, unknown>): Promise<{ content: A
4488
4571
  } catch (err) {
4489
4572
  process.stderr.write(`telegram gateway: reply: endStatusReaction hook threw: ${err}\n`)
4490
4573
  }
4574
+ // #1664 — mark the turn's final answer as delivered when this reply
4575
+ // looks like the real answer rather than an interim ack. The
4576
+ // classification (notification-bearing OR substantive length) lives
4577
+ // in `isFinalAnswerReply`. Without this, a turn that ack'd then ended
4578
+ // with the real answer as plain transcript text (#1664) would look
4579
+ // "delivered" because replyCalled is true — and the silent-end
4580
+ // re-prompt would never engage. `rawText` is the model's own answer
4581
+ // text, measured before HTML conversion / Telegraph-link
4582
+ // substitution. Writes `turn` (pinned at executeReply entry) so the
4583
+ // flag always lands on the turn this reply belongs to.
4584
+ if (turn != null && isFinalAnswerReply({ text: rawText, disableNotification })) {
4585
+ turn.finalAnswerDelivered = true
4586
+ }
4491
4587
  }
4492
4588
 
4493
4589
  process.stderr.write(`telegram channel: reply: finalized chatId=${chat_id} messageIds=[${sentIds.join(',')}] chunks=${chunks.length}\n`)
@@ -4501,6 +4597,8 @@ async function executeReply(args: Record<string, unknown>): Promise<{ content: A
4501
4597
  }
4502
4598
 
4503
4599
  async function executeStreamReply(args: Record<string, unknown>): Promise<unknown> {
4600
+ // #1664 — pin the turn at entry; see executeReply for the rationale.
4601
+ const turn = currentTurn
4504
4602
  if (!args.chat_id) throw new Error('stream_reply: chat_id is required')
4505
4603
  if (args.text == null || args.text === '') throw new Error('stream_reply: text is required and cannot be empty')
4506
4604
 
@@ -4679,6 +4777,32 @@ async function executeStreamReply(args: Record<string, unknown>): Promise<unknow
4679
4777
  const sChatId = args.chat_id as string
4680
4778
  const sThreadId = args.message_thread_id != null ? Number(args.message_thread_id) : undefined
4681
4779
  outboundDedup.record(sChatId, sThreadId, args.text as string, Date.now())
4780
+ // #1445 cross-turn pending-async ambient. The terminal stream_reply
4781
+ // (done=true) is the user-visible anchor for any cross-turn wait
4782
+ // that follows. Capture it so if this turn ends with a pending
4783
+ // async dispatch, the framework edits THIS message in place at
4784
+ // intervals.
4785
+ pendingProgress.noteOutbound(statusKey(sChatId, sThreadId), {
4786
+ messageId: result.messageId,
4787
+ text: args.text as string,
4788
+ })
4789
+ }
4790
+ // #1664 — mark the turn's final answer as delivered. For stream_reply a
4791
+ // call with done=true IS the final answer by definition (the model
4792
+ // explicitly closed the stream). A non-terminal stream_reply chunk also
4793
+ // counts when it carries the final-answer signals — notification-bearing
4794
+ // OR substantive length — via the same `isFinalAnswerReply` predicate
4795
+ // executeReply uses. See the CurrentTurn.finalAnswerDelivered doc-comment
4796
+ // for why replyCalled is not a sufficient signal here.
4797
+ if (
4798
+ turn != null &&
4799
+ isFinalAnswerReply({
4800
+ text: (args.text as string | undefined) ?? '',
4801
+ disableNotification: args.disable_notification === true,
4802
+ done: args.done === true,
4803
+ })
4804
+ ) {
4805
+ turn.finalAnswerDelivered = true
4682
4806
  }
4683
4807
  return { content: [{ type: 'text', text: `${result.status} (id: ${result.messageId ?? 'pending'})` }] }
4684
4808
  }
@@ -5675,6 +5799,25 @@ function handleSessionEvent(ev: SessionEvent): void {
5675
5799
  // Drain any orphaned typing-wrap entries left over from a crashed
5676
5800
  // prior turn before resetting focus.
5677
5801
  typingWrapper.drainAll()
5802
+ if (ev.chatId) {
5803
+ // #1445 cross-turn pending-async ambient — backstop for the
5804
+ // `handleInbound` path's `clearPending('inbound')`. The
5805
+ // inbound path covers real user messages, but synthesised
5806
+ // wakes (subagent-handback channel turn, cron fires, vault
5807
+ // grant resumes, restart markers) push directly to
5808
+ // `pendingInboundBuffer` and bypass `handleInbound`. The
5809
+ // `enqueue` session-event fires for EVERY fresh turn atom
5810
+ // regardless of source — clearing here drops any prior turn's
5811
+ // ambient before the new turn's `noteOutbound` lands. The
5812
+ // call is idempotent so it's safe to fire in addition to the
5813
+ // inbound-path clear (for the real-inbound case, this is a
5814
+ // no-op because state was already deleted by then).
5815
+ const enqThreadId = ev.threadId != null ? Number(ev.threadId) : undefined
5816
+ pendingProgress.clearPending(
5817
+ statusKey(ev.chatId, enqThreadId),
5818
+ 'handback',
5819
+ )
5820
+ }
5678
5821
  if (ev.chatId) {
5679
5822
  // Issue #195: if a previous turn left an answer-lane stream open
5680
5823
  // (rapid steer/queue), force it to a new generation so its in-flight
@@ -5697,6 +5840,7 @@ function handleSessionEvent(ev: SessionEvent): void {
5697
5840
  startedAt,
5698
5841
  gatewayReceiveAt: startedAt,
5699
5842
  replyCalled: false,
5843
+ finalAnswerDelivered: false,
5700
5844
  capturedText: [],
5701
5845
  orphanedReplyTimeoutId: null,
5702
5846
  registryKey: null,
@@ -5815,6 +5959,22 @@ function handleSessionEvent(ev: SessionEvent): void {
5815
5959
  // #1067: snapshot at entry. The answer-stream creation closures
5816
5960
  // below also read `turn` instead of currentTurn so they pin to
5817
5961
  // this turn's chat for the stream's lifetime.
5962
+ //
5963
+ // #1664 ordering note: a `text` event can arrive AFTER turn_end has
5964
+ // nulled currentTurn (the issue observed `answer_lane_update
5965
+ // transport:"draft"` firing post-turn_end). Such a late event is
5966
+ // dropped here by the `turn != null` guard — it is NOT folded back
5967
+ // into the just-ended turn. That is deliberate and safe: by the
5968
+ // time this fires, the turn atom has been handed to
5969
+ // endCurrentTurnAtomic and turn_end has already run its flush /
5970
+ // silent-end decision; re-opening a closed turn (re-creating an
5971
+ // answer stream, re-evaluating decideTurnFlush) would be a large,
5972
+ // race-prone change. The #1664 safety net does not depend on
5973
+ // catching the late text: a turn whose real answer lost the race
5974
+ // ends with finalAnswerDelivered=false, so recordUndeliveredTurnEnd
5975
+ // engages the Stop-hook re-prompt and the model re-delivers the
5976
+ // answer through the reply tool. The dropped draft text is
5977
+ // recovered by re-prompt, not by post-hoc materialization.
5818
5978
  const turn = currentTurn
5819
5979
  if (turn != null) {
5820
5980
  turn.capturedText.push(ev.text)
@@ -5975,6 +6135,7 @@ function handleSessionEvent(ev: SessionEvent): void {
5975
6135
  // full message above). Match the pattern used at the regular
5976
6136
  // turn-end path (line ~5039) and the wedged-turn path (~5290).
5977
6137
  silencePoke.endTurn(ceKey)
6138
+ pendingProgress.noteTurnEnd(ceKey)
5978
6139
  // Issue #195: tear down the answer-lane stream on context-exhaustion
5979
6140
  // bail-out. The user is being told the session needs /restart, so any
5980
6141
  // partially-streamed answer would be misleading.
@@ -6160,6 +6321,7 @@ function handleSessionEvent(ev: SessionEvent): void {
6160
6321
  try { removeTurnActiveMarker(STATE_DIR) } catch { /* best-effort */ }
6161
6322
  signalTracker.clear(tKey)
6162
6323
  silencePoke.endTurn(tKey)
6324
+ pendingProgress.noteTurnEnd(tKey)
6163
6325
  }
6164
6326
  lastPtyPreviewByChat.delete(statusKey(chatId, threadId))
6165
6327
  pendingPtyPartial = null
@@ -6181,6 +6343,18 @@ function handleSessionEvent(ev: SessionEvent): void {
6181
6343
  const backstopThreadId = threadId
6182
6344
  const backstopCtrl = ctrl
6183
6345
 
6346
+ // #1664 — turn-flush only fires when !replyCalled (decideTurnFlush
6347
+ // returns 'reply-called' otherwise). It legitimately delivers the
6348
+ // model's terminal text as the answer, so the turn IS answered.
6349
+ // Mark it now so the early-return below skips the silent-end
6350
+ // re-prompt for a turn whose answer is genuinely on its way out.
6351
+ // (The IIFE that actually sends runs after this branch's `return`;
6352
+ // since the silent-end block is on the sibling reply-called path
6353
+ // that this branch never reaches, this set is belt-and-braces —
6354
+ // it keeps the captured `turn` atom internally consistent for any
6355
+ // future reader.)
6356
+ turn.finalAnswerDelivered = true
6357
+
6184
6358
  // #654 deterministic double-message fix. Hand off the pinned
6185
6359
  // progress card BEFORE state reset so the driver doesn't keep
6186
6360
  // editing it while turn-flush is rewriting it with the answer.
@@ -6222,6 +6396,7 @@ function handleSessionEvent(ev: SessionEvent): void {
6222
6396
  const tKey = statusKey(chatId, threadId)
6223
6397
  signalTracker.clear(tKey)
6224
6398
  silencePoke.endTurn(tKey)
6399
+ pendingProgress.noteTurnEnd(tKey)
6225
6400
  }
6226
6401
 
6227
6402
  void (async () => {
@@ -6413,17 +6588,31 @@ function handleSessionEvent(ev: SessionEvent): void {
6413
6588
  longest_silent_gap_ms: outboundMetrics.longestOutboundGapMs,
6414
6589
  ended_via: outboundMetrics.outboundCount > 0 ? 'reply' : 'silent',
6415
6590
  })
6416
- // #1122 PR4 / #1161: deterministic silent-end handling (see the
6417
- // silent-marker path above for the rationale).
6418
- // - first silent-end → recordSilentTurnEnd writes the state
6419
- // file so the Stop hook (silent-end-interrupt-stop.mjs)
6420
- // blocks the session-end and re-prompts the agent to reply.
6591
+ // #1122 PR4 / #1161 / #1664: deterministic undelivered-turn
6592
+ // handling (see the silent-marker path above for the rationale).
6593
+ // - first undelivered turn-end → recordSilentTurnEnd writes the
6594
+ // state file so the Stop hook (silent-end-interrupt-stop.mjs)
6595
+ // blocks the session-end and re-prompts the agent to deliver.
6421
6596
  // - the Stop-hook re-prompt is already spent and the agent is
6422
- // STILL silent → recordSilentTurnEnd returns exhausted:true;
6423
- // deliver a user-facing fallback so the turn never just
6424
- // vanishes (the user otherwise only sees the card disappear).
6425
- if (outboundMetrics.outboundCount === 0) {
6426
- const silentEnd = recordSilentTurnEnd({
6597
+ // STILL undelivered → recordSilentTurnEnd returns
6598
+ // exhausted:true; deliver a user-facing fallback so the turn
6599
+ // never just vanishes (the user otherwise only sees the card
6600
+ // disappear).
6601
+ //
6602
+ // #1664 — the trigger is "no final answer delivered", not "zero
6603
+ // outbound". `outboundCount === 0` is now just the special case
6604
+ // where nothing landed at all. The added case: the model sent an
6605
+ // interim ack via reply/stream_reply (outboundCount > 0,
6606
+ // replyCalled = true) but ended the turn with its real answer as
6607
+ // plain transcript text — rendered into an ephemeral answer-lane
6608
+ // draft and retracted at turn_end, never finalized. finalAnswer-
6609
+ // Delivered stays false there, so the re-prompt engages and the
6610
+ // model re-delivers the answer through the reply tool. NO_REPLY /
6611
+ // HEARTBEAT_OK silent-marker turns return earlier and never reach
6612
+ // this path. The turn-flush 'flush' branch also returns earlier
6613
+ // (and sets finalAnswerDelivered=true defensively).
6614
+ if (turn.finalAnswerDelivered === false) {
6615
+ const silentEnd = recordUndeliveredTurnEnd({
6427
6616
  chatId,
6428
6617
  threadId: threadId ?? null,
6429
6618
  turnKey: tKey,
@@ -6454,6 +6643,7 @@ function handleSessionEvent(ev: SessionEvent): void {
6454
6643
  }
6455
6644
  signalTracker.clear(tKey)
6456
6645
  silencePoke.endTurn(tKey)
6646
+ pendingProgress.noteTurnEnd(tKey)
6457
6647
  }
6458
6648
  lastPtyPreviewByChat.delete(statusKey(chatId, threadId))
6459
6649
  pendingPtyPartial = null
@@ -7676,6 +7866,18 @@ async function handleInbound(
7676
7866
  // the framework can nudge the model if it goes quiet past the
7677
7867
  // soft / firm thresholds.
7678
7868
  silencePoke.startTurn(statusKey(chat_id, messageThreadId), Date.now())
7869
+ // #1445 cross-turn pending-async ambient. A new turn starting
7870
+ // (user inbound, synthesised wake, or handback channel) is the
7871
+ // signal that the model is about to re-engage — clear any
7872
+ // pending-progress edits anchored to the *prior* turn's
7873
+ // outbound so the framework stops talking over the new turn.
7874
+ // clearPending drops the per-key state outright, so the new
7875
+ // turn's `tool_use(Agent|Task|Bash bg)` + outbound capture
7876
+ // afresh via `noteAsyncDispatch` / `noteOutbound`.
7877
+ pendingProgress.clearPending(
7878
+ statusKey(chat_id, messageThreadId),
7879
+ 'inbound',
7880
+ )
7679
7881
  // Human-feel UX: hold a continuous `typing…` indicator for the
7680
7882
  // WHOLE turn, not just the split-second a reply is transmitted.
7681
7883
  // A person you message shows as typing the entire time they
@@ -2,12 +2,20 @@
2
2
  /**
3
3
  * Stop hook — auto-interrupt for silent-end turns.
4
4
  *
5
- * When a Claude Code session ends without the agent having called reply or
6
- * stream_reply (a "silent-end"), the Telegram gateway writes a state file at
5
+ * When a Claude Code session ends without the agent delivering a final
6
+ * answer to the user, the Telegram gateway writes a state file at
7
7
  * $TELEGRAM_STATE_DIR/silent-end-pending.json. This hook reads that file and,
8
8
  * if a first-time silent-end is detected (retryCount === 0), returns a
9
9
  * decision:block to re-prompt the agent instead of letting the session close.
10
10
  *
11
+ * #1664 — "no final answer delivered" covers two cases: (a) the turn ended
12
+ * with zero outbound (the original case), and (b) the model sent only an
13
+ * interim ack via reply/stream_reply but left its real answer as plain
14
+ * transcript text, which the gateway renders into an ephemeral draft and
15
+ * never finalizes. The re-prompt below tells the model to send its answer
16
+ * through the reply tool, or reply NO_REPLY if it genuinely has nothing to
17
+ * add / already delivered.
18
+ *
11
19
  * On the second silent-end (retryCount >= MAX_RETRIES), the hook allows the
12
20
  * stop. The gateway's turn-end path (recordSilentTurnEnd in silent-end.ts)
13
21
  * detects the exhausted re-prompt and delivers a user-facing fallback
@@ -104,9 +112,13 @@ function main() {
104
112
  JSON.stringify({
105
113
  decision: 'block',
106
114
  reason:
107
- 'You ran tools but never sent a reply to the user. ' +
108
- 'Call mcp__switchroom-telegram__reply or mcp__switchroom-telegram__stream_reply (with done=true) ' +
109
- 'to send your final answer now.',
115
+ 'This turn is ending without your final answer reaching the user. ' +
116
+ 'If you wrote an answer as plain text (not via a tool), the user ' +
117
+ 'cannot see it only text sent through the reply tool is delivered. ' +
118
+ 'Send your final answer now by calling mcp__switchroom-telegram__reply ' +
119
+ '(or mcp__switchroom-telegram__stream_reply with done=true). ' +
120
+ 'If your final answer has already reached the user, or you ' +
121
+ 'intentionally have nothing to add, reply with exactly NO_REPLY.',
110
122
  }),
111
123
  )
112
124
  process.exit(0)